feat(build.mcpp): the rule-package specification, a portable check role, and modules/ for mcpp itself (2026.8.29.1) - #525
Merged
Conversation
… mcpp itself The mechanism for distributing a build rule as an ordinary package landed in 2026.8.5.1 and works. What was missing was the specification: what a rule package may be called, what it may depend on, and where its dependencies go. The index holds one rule package out of 132 descriptors, so every property of the abstraction rested on a single sample. Identity is enforced by the engine; shape is documented for authors. The split is not a matter of taste: in the previous round exactly one rule was written as a pure convention -- `mcpp.rules.*` as a reserved prefix -- and it is the one rule that was silently abandoned during implementation. It was also unimplementable from the moment it was written, since module names came from the bare `package.name` and SPEC-001 forbids a dot there. ## `[build-dependencies]` now does something The section has been parsed, merged across workspace members and conditionalised by target predicate for a long time, and no code path read it to make a decision; `types.cppm` annotated the field "host-side tools (M5+ behavior)". Writing it produced a manifest that loaded, no diagnostic, and no effect. The section and the per-edge request answer orthogonal questions. The section says whether the package itself reaches the target; the request (`tools`, `host-module`) says which build-time product is wanted. protobuf is the case that proves they must stay separate: a project links libprotobuf and needs protoc, written once in `[dependencies]`. ## A rule may depend on another rule Declared in the rule's own `[build-dependencies]`. The mechanism is ordering, not new machinery: build_program.cppm accumulates the module flags as it compiles host modules in list order, so the caller topologically sorts the list and each entry sees the BMIs ahead of it. BMI agreement stays structural because the flag set does not change. The consumer may not import what it never declared. Provisions cross one further edge only on a `reexport = true` edge, and mcpp enforces that rather than leaving it to the compiler -- under GCC the import would work and then fail on someone else's machine. Rules used to be leaves, and that was not a design choice: both live build-time channels are written by the CONSUMER on an edge, so a rule had no way to request anything on its own behalf. ## Fixes A rule's own `[dependencies]` were compiled and linked into the consumer's binary. The exclusion predicate was per-edge -- "every in-edge is a host-module edge" -- which is right about the rule and wrong about everything behind it. Restated as forward reachability from the target's dependency roots, which also makes the dual-role case right without a special case: a package the project depends on directly stays in the target even when a build-time path reaches it too. Measured in tests/e2e/310: with the old predicate the library's symbol is in the binary; with the new one it is not. A rule's module name is now what its source declares. `build_host_module` binds that name differently per compiler family -- `/reference name=ifc` on MSVC, `-fmodule-file=name=pcm` on Clang, and nothing at all on GCC, whose BMIs are implicit under gcm.cache and keyed by the declared name. A rule whose lib root declared anything else built on GCC and failed on the other two. The successful import therefore does not discriminate on Linux, so the e2e asserts on the BMI and object filenames instead. Two rules declaring one module name are refused, naming both packages and both interface paths. Without the check both compiles write one object, the second overwrites the first, and the survivor reaches the link twice as `multiple definition of 'initializer for module X'` -- one file named twice, one package named twice, and no mention that two packages are involved. `mcpp.` is reserved for rules maintained by the mcpp project. A warning, not an error: the engine cannot decide who is official, and a path dependency, a private mirror and an internal fork are all legitimate. ## `modules/` Six leaf modules move out of `src/` into independent packages used by path: json, toml, log, source-kind, dyndep, version-req. Grouping mcpp's 157 modules by directory and computing strongly connected components puts 17 of 21 groups in one component, which reads as "cannot be split" until the back-edges are counted: `platform` has five, four of them inside `platform/xlings/`, which is a different layer wearing platform's name. `modules/` holds what is linked into the binary and `src/` is what has not been separated yet. No `apps/`: mcpp produces one binary, and a directory with one member and no distinction behind it has to be explained every time it is read. Adding a module means editing four files and three of those omissions fail later and elsewhere -- the worst only on a macOS machine with no mcpp to rebuild with. `.github/tools/check_modules_wiring.sh` holds them together, and each of its checks was observed failing before it was trusted. ## Tests Four e2e files and eleven unit tests, each seen failing against the previous behaviour before being kept. Two claims in the design document were corrected by measurement while doing so: the collision is a link error rather than a silent success, and the divergent-name import passes on GCC.
…nifest The earlier wording said a rule should carry its own version rather than the wrapped tool's. grpcgen ships from one tag with grpc and grpc-plugin and its repository's CI checks the three never drift, which is a justified practice the rule would have contradicted -- the coupling is real, since the rule drives a specific grpc_cpp_plugin. The invariant that does hold is narrower: one (name, version) names one payload. grpcgen's defect is that tag v1.83.0-4 published a fourth payload under version 1.83.0, not its choice of numbering.
…nsulted Self-review found the refusal sitting after the cache fast path returns. Withdrawing `reexport = true` from an edge leaves the module set and every interface hash identical, so nothing in `compilerIdentity` moves -- the cached program would be replayed and the now-illegal import would never be reported. Measured: it fires anyway today, because `ctxHash` happens to change too. That is an incidental coupling between two keys that answer different questions, and this codebase has paid for that shape repeatedly. Adding `importable` to the cache identity would have been a second place deriving the same decision; asking before the cache is consulted needs no key at all. `srcText` is hoisted to where the check now lives and read once. e2e 311 gains a section that runs on a warm cache, with the measurement above written into it: the assertion pins the behaviour, and the placement is what makes it hold.
… them
The unit under `modules/` is a SUBSYSTEM, not a file. One file per package
would turn a directory listing into nine manifests and call it architecture.
libs json + toml -- text-format parsers, vendored and in-house
log leveled diagnostics
versioning mcpp.version + mcpp.version_req
source-kind the source-file role table
dyndep ninja dyndep emission
platform the OS abstraction: fs, process, axis, shell, terminal
manifest mcpp.toml and the xpkg descriptor, plus the vocabulary they
are written in
toolchain-model what a toolchain IS -- triple, model, dialect, cppfly,
fingerprint, linkmodel
buildmcpp the build.mcpp CONTRACT -- program_protocol, directives,
provisions, tool_store
Dependencies between them are declared and enforced rather than assumed, so
`mcpp test -p <member>` builds each one ALONE and a subsystem relying on
something it does not declare fails there while compiling fine in the root.
## Three cuts, one mistake
Every group-level cycle was produced by a countable set of back-edges, and each
one was a module named for where it sat rather than for what it did.
`src/platform/xlings/` was never platform: it is the xlings integration, which
by its nature knows about packages, indexes and manifests. Its modules were
named `mcpp.platform.xlings` while their namespace already said
`mcpp::xlings`; both now agree, in `src/xlings/`.
`runtime_binding.cppm` imported the whole of `mcpp.config` to read ONE path out
of it, which put the OS layer above configuration, xlings and the package
manager. The caller passes the path; the file describes the xlings runtime
contract rather than the OS, so it moved to `src/runtime/` with its only
dependent.
`toolchain.fingerprint` imported `toolchain.detect` for a type -- `Toolchain`
-- that `toolchain.model` defines and `detect` merely re-exports. The import
named a CONSUMER of the type rather than its provider and dragged toolchain
detection behind it. That one is load-bearing: every module on the
build-program side needs `fingerprint` for `hash_file`, so while that edge
pointed at `detect` the whole build.mcpp contract sat transitively above the
package manager. One import line was the entire distance.
## What stays in src/, and why it is not a defect
`build_program.cppm` and `hostprogram.cppm` -- the half that compiles and runs
the program -- need `toolchain.registry` and `toolchain.stdmod`, which reach
into detection, the compiler families and xlings. A thing that compiles and
runs a program is a toolchain consumer by construction; moving it would
relocate the dependency rather than remove it.
## Tests at two altitudes
Each package carries its own tests, and the root keeps a suite that crosses
layers. A subsystem test going red means the contract changed; the root's
cross-check going red means the contract held and a consumer drifted.
`tests/unit/test_subsystem_contracts.cpp` is the new one: it asserts that the
protocol number the bundled module announces is SUBSTITUTED rather than spelled
(so the announced value cannot drift from the checked one), and that the
compiled binary's version equals the root manifest's.
⚠️ `mcpp test -p <member>` exits 0 for a member with no tests, so CI's loop is
green either way and "has no tests" reads exactly like "tests pass".
`check_modules_wiring.sh` prints which members have none, and does not fail on
them.
`versioning` is not called `version`: that word is a reserved key inside a
dependency specification, so `[dependencies.mcpp] version = { path = … }`
parses as the mcpp dependency's version field and fails with an error about the
wrong table.
Every path a reader can click has to exist. Six of them named files that moved into modules/ this round -- the linkmodel, the triple table, the manifest types, and the version constant.
… portable A `role = "check"` action's verdict is its EXIT CODE; the stamp is bookkeeping the graph needs. Requiring the command to produce it made the role unusable for the analysers it exists for -- clang-tidy writes nothing on success -- and the obvious workaround, a wrapper script, cannot be written portably: an action's command is an argv with no shell assumed, which is correct for Windows and is exactly what leaves nothing there to touch a file with. So the role with no ecosystem consumer also had no portable way to acquire one, and that was not a coincidence. Found by trying to write the second real rule package rather than by reading the code, which is what section 4 of the design document predicted would happen. The engine is the portable wrapper. `mcpp __action-stamp <stamp>... -- <argv>...` is internal and absent from the usage text; mcpp is already on disk on every platform it runs on, so this needs no shell, no `touch`, and no per-platform spelling. Stamps are written only on success -- writing them anyway would let ninja consider the edge satisfied and skip a check that had never passed. Backward compatible: existing files are left alone, so the wrapper scripts in e2e 188 keep working byte-for-byte. ## The obvious assertion does not discriminate Measured: ninja does NOT fail when a declared output goes unproduced. It leaves the file absent and re-runs that edge on every subsequent build, so the build stays green and the only symptom is work silently redone. A test that checked exit codes would have passed with the wrapper removed -- it did. `tests/e2e/312` therefore asserts on the stamp's existence and on the check NOT re-running, and both were seen red before being kept. The example's `check.sh` now does what an analyser does: read a file, answer with an exit code, touch nothing. ## The suite says when it was measuring two engines Rebuilding mcpp during the ~20-minute e2e run swaps the binary underneath it; later tests then measure a different engine from earlier ones and the report mixes the two silently. Measured twice in one session -- three tests "failed", all three passed on a stable binary. A comment asking people not to do it is what failed the second time, so `run_all.sh` records the binary's hash at the start and refuses to let the summary be read as one build's result if it changed.
…inking
Three things the subsystem split moved out from under checks that name paths,
all found by sweeping rather than by a failure.
`bootstrap-mcpp/action.yml` keyed target/ on `hashFiles('src/**', …)`. Half of
mcpp's sources now live under `modules/`, so editing one would restore a
target/ built from different sources and report success -- the exact failure a
cache key exists to prevent, arriving silently.
`check_narrow_conversions.sh` scanned `src/modgraph src/scaffold`. The glob
walker it was written for moved to `modules/manifest/src/glob.cppm` and left
the scope without changing what the pass printed. A scope written as directory
names shrinks silently when a file moves, so the file is now named directly and
its ABSENCE is a hard error.
`check_modules_wiring.sh` gains the cache-key check.
⚠️ Its first version passed the negative test: it asked whether the FILE
mentioned `modules/**`, and was satisfied by the comment sitting above the key
explaining why `modules/**` belongs there. A check a comment can satisfy is
checking the prose. The unit is the line.
Both new checks were seen failing before being kept -- the narrow-conversion
one by injecting a narrowing into the moved file, since it legitimately has
none today.
Also: the descriptor count in the design document was taken from
`mcpp-community/mcpp-index` while `mcpp index list` reports
`mcpplibs/mcpp-index` as the default. The right question asked of the wrong
object; the conclusion survived (one rule package either way) and the number
did not. 133, and the document now says which index.
Section 12.6 said to measure before moving a file, because mcpp's build is critical-path bound and a package boundary that made the root wait for each dependency's archive would be paid in full wall clock. Cold build, empty target/, same machine: 51.07s before the split, 49.52s after. Within noise of each other, which answers the question the section was gating on. The number to watch if this is revisited is the difference, not either figure -- both are dominated by the same critical path.
$MCPP_FRESH is the newest mcpp anywhere under target/, which is correct in this job because nothing between the build and the tests writes another one. But `mcpp pack` leaves a copy under target/dist/, and a pack step inserted above would silently redirect both this loop and the suite before it to the packaged binary -- observed locally while verifying the loop. The shared expression is not changed here (three other jobs use it); the loop just refuses to run against a path it should never see.
V9 (a check whose command never touches its stamp) and V10 (withdrawing reexport on a warm cache), plus where each criterion actually lives. V9 carries the measurement that makes it non-obvious: ninja does not fail when a declared output goes unproduced, so asserting on the build succeeding would have passed with the mechanism removed. It did.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three things that turned out to be one thing: a specification for build-rule
packages, the engine changes that make the spec true, and mcpp's own source tree
split into subsystem packages so the contract can be maintained as a unit.
Design:
.agents/docs/2026-08-29-build-rule-package-spec.mdThe plugin framework
build_host_modulebinds it differently per family —/reference name=ifc(MSVC),-fmodule-file=name=pcm(Clang), and nothing at all on GCC, whose BMIs are implicit undergcm.cachekeyed by the declared name. A divergent name built on GCC and failed on the other two. The successful import therefore does not discriminate on Linux, so the e2e asserts on the BMI and object filenames instead.multiple definition of 'initializer for module X'— one file named twice, one package named twice, no mention that two packages are involved.[build-dependencies]now does something// host-side tools (M5+ behavior). Writing it produced a manifest that loaded, no diagnostic, and no effect.[build-dependencies]. The mechanism is ordering, not new machinery: the compile loop accumulates module flags in list order, so the caller topologically sorts and each entry sees the BMIs ahead of it. The consumer may not import what it never declared — provisions cross one further edge only onreexport = true, and mcpp enforces that rather than leaving it to GCC, which would allow it and then fail on someone else's machine.[dependencies]no longer leaktests/e2e/310: with the old predicate the library's symbol is in the binary.role = "check"is portable nowmodules/— nine subsystemsThe unit is a subsystem, not a file.
src/keeps the build tool's skeleton— prepare/plan/execute, the toolchain families and detection, pm, pack, cli,
xlings, runtime — and shrinking it is the direction.
Grouping mcpp's 157 modules by directory and computing SCCs puts 17 of 21
groups in one component, which reads as "cannot be split" until the back-edges
are counted. Three were cut, and each was the same mistake wearing different
clothes — a module named for where it sat rather than for what it did:
src/platform/xlings/was never platform. It is the xlings integration, whosenamespace already said
mcpp::xlingswhile its module names said otherwise.runtime_binding.cppmimported the whole ofmcpp.configto read one path,putting the OS layer above configuration, xlings and the package manager.
toolchain.fingerprintimportedtoolchain.detectfor a type thattoolchain.modeldefines anddetectmerely re-exports — a consumerof the type rather than its provider. That one is load-bearing: every module on
the build-program side needs
fingerprintforhash_file, so while that edgepointed at
detectthe whole build.mcpp contract sat transitively above thepackage manager. One import line was the entire distance.
build_program.cppmandhostprogram.cppmstay insrc/: they need thetoolchain registry and the std-module builder, and a thing that compiles and runs
a program is a toolchain consumer by construction.
Tests at two altitudes
Each package carries its own tests (
mcpp test -p <member>, run per member inCI) and the root keeps a suite that crosses layers. A subsystem test going red
means the contract changed; the root cross-check going red means the contract
held and a consumer drifted. The subsystem tests also build in a configuration
the root never produces — the package alone, with only its declared
dependencies.
mcpp test -p <member>exits 0 for a member with no tests, socheck_modules_wiring.shprints which have none rather than letting silence readas a pass.
What measurement overturned
Written into the design document at the places they apply, because the reasoning
is the useful part:
carry the feature on Linux;
that edge forever, so a check without a stamp looks like a passing check;
one tag with grpc and that repository's CI enforces it.
Verification
Four e2e files (309–312) and 14 unit tests, each seen failing against the
previous behaviour before being kept. Locally: unit 96/0; e2e 300+ green with
the remaining failures reproduced on the released 2026.8.25.2 binary and traced
to this host (no
python3in the active subos, no std module source in the muslpayload, no
crti.oin the binutils payload).run_all.shnow records the binary's hash at the start: rebuilding mcpp duringthe ~20-minute suite swaps the engine underneath it, and the report otherwise
mixes two builds silently. Measured twice in one session.
Ecosystem
manifest said the package name is the module name, true before this release
and false after. Verified that grpcgen registers the identical module name on
both engines.
clangtidyrule package is written and verified against this branch: cleanfile passes and is incremental, a violation fails the build at the check edge
with no stamp. It is held until the release, since it requires it.